C++ 模板穿透技术

2024-10-23 10:00:00

假如有这样一个需求,对于模板类D<C<B<std::tuple<A0, A1, A2>>>>,我们希望类模板std::tuple穿过其外层的尖括号,并将外层的模板映射到其每个元素之上,即得到新的模板类std::tuple<D<C<B<A0>>>, D<C<B<A1>>>, D<C<B<A2>>>>,如何做到?这固然可以手动实现,但我们希望编译器能完成自动推导,因为如果类模板D的外层还嵌套着EF等等呢?我们的目标是给出一个通用的解决方案。

简便起见,我们假设类模板DCB都是空的:

template <typename Inner>
struct D {};

template <typename Inner>
struct C {};

template <typename Inner>
struct B {};

A0A1A2也都是空的:

struct A0 {};
struct A1 {};
struct A2 {};

上述问题显然符合递归求解的思想,只不过这一次我们不再在运行时递归,而是在编译期递归

为了阐述下文,我们还不得不简要介绍一下模板的模板参数

在实例化一个模板的时候需要确定其参数,那么这个参数可以是什么呢?最容易理解的就是类型参数,也是最符合直觉的,就是司空见惯的template <typename T>里面的T。第二种是值参数,比如std::array<double, 6>里的常量6就是值参数,并且值参数严格限定为整数或枚举类型。最后一种,就是模板的参数也可以是一个模板,叫做模板的模板参数,比如对于template <template <typename> typename T> struct X : T<double> {};,那么X<std::vector>将继承自std::vector<double>,而X<std::set>将继承自std::set<double>,这里无论是传std::vector还是std::set,传入的都是一个模板。总而言之,模板的参数一共有三种形式:

  1. 类型
  2. 模板

懂了模板的模板参数,我们就可以尝试将D<C<B<std::tuple<A0, A1, A2>>>>嵌套的模板一层一层扒开:

template <template <typename> typename Outer, typename Inner>
auto foo(Outer<Inner>) -> Outer<decltype(foo(Inner{}))>
{
    return {};
}

上面这段代码相当之抽象,而且相较于经典 C++ 已经面目全非了。我们抽丝剥茧解读一下:

这段代码还缺失一个重要功能,就是递归的终止条件。显然,递归的“递”应该在Innerstd::tuple<A0, A1, A2>时终止。所以,我们需要为foo函数再写一个偏特化的版本:

template <template <typename> typename Outer>
auto foo(Outer<std::tuple<A0, A1, A2>>) -> Outer<A0>
{
    return {};
}

这样,当把D<C<B<std::tuple<A0, A1, A2>>>>传入foo函数,最终的返回类型就是D<C<B<A0>>>。最后,我们再为A1A2编写类似的函数,再用std::tuple将三个结果整合,就解决了。

但是,这样我们需要写三个函数,加上偏特化,一共是六个,并且它们高度相似,还是没做到尽可能的复用代码,并不想就此止步。考虑到std::tuple_element_t可以用来萃取std::tuple的元素类型,于是我们有了下面的改进版:

template <int n, template <typename> typename Outer, typename Inner>
auto foo(Outer<Inner>) -> Outer<decltype(foo<n>(Inner{}))>
{
    return {};
}

template <int n, template <typename> typename Outer>
auto foo(Outer<std::tuple<A0, A1, A2>>) -> Outer<std::tuple_element_t<n, std::tuple<A0, A1, A2>>>
{
    return {};
}

在这个版本中,我们利用了模板函数调用可以部分指定参数的特性,将值参数n一层一层传下去,并在最底层提取出std::tuple<A0, A1, A2>的第n个元素类型。

最终,再用一个函数把结果们整合起来:

using T = D<C<B<std::tuple<A0, A1, A2>>>>;
auto bar(T)
{
    return std::tuple(
        foo<0>(T{}),
        foo<1>(T{}),
        foo<2>(T{})
    );
}

我们有两种方式来验证bar函数的返回类型是不是我们所期望的:

static_assert( std::is_same_v< decltype(bar(T{})), std::tuple<D<C<B<A0>>>, D<C<B<A1>>>, D<C<B<A2>>>> > );

这样最符合直觉,但是又回到了一开头不够通用的问题。

template <typename...>
struct check;

int main()
{
    check<decltype(bar(T{}))>{};
}

这是一个很妙的 trick,因为我们压根没有实现check类模板,编译器必然报错,而报错结果中就有我们所需的答案,做到了通用。以g++为例,

从报错结果中可以清楚看到我们推导成功了,我们成功地完成了模板的穿透。


但是,我们紧接着就得解释这么做有何意义,难道只是在玩智力游戏?而且你那些DCB都是空的,有啥现实意义啊。

实际上,模板穿透是我从一个真实的项目需求中提炼出来的,不妨就以此来解释。航天器的轨道可以由时间序列和状态序列组成。时间我们用double类型,状态用std::array<double, 6>类型,那么时间序列可以表示成using Tlist = std::vector<double>;,状态序列可以表示成using Ylist = std::vector<std::array<double, 6>>;,轨道则为using Orbit = std::tuple<Tlist, Ylist>;

我们还知道 C++ 标准库有一个非常 fancy现代未来是属于它的的工具库叫 ranges(太多东西想讲了,留到下次吧),它提供了诸多views,比如std::ranges::reverse_view可以逆序访问容器。我们想让std::ranges::reverse_view<Orbit>变成std::tuple<std::ranges::reverse_view<Tlist>, std::ranges::reverse_view<Ylist>>,这就需要用到模板穿透技术。

但是,我们仍然不得不再做一些声明,以免误导大家:

  1. std::tuple是不可以直接传入std::ranges::reverse_view的,因为它不符合 ranges 的概念,上面那么书写只是起演示作用。在真实项目中,我们是对Orbit类做了容器化改造的。
  2. views最令人拍手叫绝的正是多个views的灵活串联,看似跟我们的多模板嵌套恰好契合,然而我们的这个应用示例还是太理想化了,因为std::ranges::reverse_view恰好只接受一个参数,可惜绝大多数views是接受多参数的,比如std::ranges::drop_viewstd::ranges::stride_view等等,它们都不适用于模板穿透。
  3. std::ranges::reverse_view不是空类,所以foo函数不可以再直接return {};,还需要适应性改造,具体如何做就留给大家思考吧。

总之,这项技术确实没太明显的实用价值,我本人也更多地将其作为一种技术储备,但是也不能抹杀它对于逻辑的锻炼作用,尤其是对于理解编译期递归大有裨益。

Author

青崖同学

Release

2024-10-23 10:00:00

License

Creative Commons